Skip to content

fix(gates): irrecoverable reads each global value as one shell word (GH #375) - #383

Merged
wasikarn merged 2 commits into
developfrom
claude/gate-375-git-c-var
Oct 3, 2026
Merged

wasikarn merged 2 commits into
developfrom
claude/gate-375-git-c-var

Conversation

@wasikarn

@wasikarn wasikarn commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Closes #375. Follow-up for the sibling walks: #382.

Root cause

_tokens runs shlex with punctuation_chars and no whitespace_split. A character outside shlex's wordchars ends the token, so one shell word becomes several tokens: $PWD is $, PWD; a:b, a@b.c and a Thai path split the same way. The git global walk skips one token per value, so in git -C $PWD reset --hard it took PWD for the subcommand and no git rule saw reset. The same split hit -C$R, --git-dir=$G, -c k=$X, -c user.email=a@b.c and -C ~/<Thai path>. These all exit 0 on develop.

Fix

  • _tokens returns a token that shlex cut from the one before it, with no blank between them, as _Glued, an equal str subclass. Every other check reads the same strings as before. Glue is read from the lexer: a token cut at a non-word character pushes that character back, and a one-character token ($) ends without reading on.
  • In the git block, _git_words re-reads the leading global flags with each glued run joined into one shell word, using the same walk (a value-taking global takes the next word). When anything was joined, ["git"] + words is checked as a window of its own. The token reading stays, so a deny from either reading wins and no develop deny is lost. The subcommand word's own glue does not count, so the new window never re-appends itself.
  • _sets_hooks_path gets the joined value too, so -c core.hooksPath=$H now denies.
  • Wrappers need nothing extra: sudo, env, xargs and find -exec reach the same git block.

var-verb (#376)

Its only deny hit in the replay, R=...; git -C $R checkout -- <file>, is now denied by git-checkout-discard in the main pass, before any view runs. On this PR's replay, would_deny:var-verb goes from 1 to 0, and its 2 would_ask rows are unchanged (one is a real GH=gh; $GH pr merge read through the view). So var-verb is redundant for that hit. It stays in SHADOW_RULES; promoting it is a separate call. The 336 fixture row for that command is now DENY.

Tests (red first)

  • tests/hooks/fixtures/irrecoverable-375-cases.txt, replayed by test-gates.sh: 41 DENY rows and 17 ALLOW controls. 40 DENY rows were red on develop: every destructive rule after the variable (reset --hard, push --force / -f / +ref, clean -f, checkout --, restore, switch -f, branch -D, stash drop/clear, commit --amend / -n, add -A), every value global, glued and spaced forms, other split characters, and the sudo / env / xargs / find routes. The 41st, git -C $reset --hard, already denies on develop and stays to pin the token reading (not destructive: with reset unset git gets -C --hard). Controls: git -C $REPO status, git -C "$d" log, git -C $x diff, git -c user.email=a@b.c commit -m x, git -C ~/<Thai> status, git -C a:b diff, git -C $ status, a for d in */ loop and others. An ALLOW control also fails if it journals a row.
  • Real shells: every DENY shape in sections 1-5 ran in /bin/bash 3.2, bash 5.3, /bin/dash, /bin/zsh and /bin/sh, with the variables set in the environment and a logging git stub. Each shell's logged argv, quoted back, is denied by develop, so every row really runs a destructive git argv.
  • 5 timing rows: 4,000 -C $R globals before reset --hard and before status, 2,000 git -C $R status; statements then a reset, a 20 KB glued $a$a... value, and glue only inside the subcommand word (must not loop).
  • Mutation check, 10/10 killed against the committed rows: no glue, glue only from pushback, glue only after a one-character token (killed by ~/my\ $R), glue ignoring the blank before, glue ignoring the blank after, join every token, value global taking no word, no glued window, subcommand glue counted (an endless window loop, killed by the status$X timing row), and the window replacing the token reading (killed by git -C $reset --hard).
  • test-gates.sh 1310/1310, test-gate-journal.sh 30/30 after the rebase. harness-audit 0 CRIT. Gauntlet passes with GAUNTLET_JOBS=2.

Differential (frozen develop vs this branch, one process, every payload as main and as agent)

Compared (exit code, stdout, stderr, journal rows). No exit other than 0/2 except 2 payloads in the test fixtures (a list-valued command, the same traceback in both).

corpus payloads diffs
test-gates payloads, recorded 1,245 44 allow->deny, all fixture rows of this PR
replay: 115,308 distinct real Bash commands from local transcripts 230,616 6 allow->deny = 3 commands x 2 modes
own fuzz, seed 1 9,000 (4,500 x 2 modes) 1,844 allow->deny
own fuzz, seed 2 9,000 1,860 allow->deny

No verdict got looser, no ask changed, no message changed on a command that was already denied, and no diff was journal-only.

Replay, one by one (3 distinct commands, now denied):

  1. A test script whose git -C $R checkout -- <file> discards a working-tree file (the var-verb hit). Destructive.
  2. A scratch-repo test script holding git -C $PCR reset -q --hard and git -C $PCR add .. Destructive.
  3. A scratch-repo test script whose only newly caught line is git -C $PCR add . (git-add-all). This is the 1 false deny by the "runs a destructive git subcommand" bar: staging everything is policy, not data loss. The gate already denies git add . and git -C "$PCR" add . in every other spelling.

Fuzz, real shells (each distinct diffed command run in 5 shells with a stub, argv checked against develop): 1,626 of 1,852 ran a destructive git argv in at least one shell. The other 226, all the safe direction:

  • 102 builtin git ...: develop already treats builtin as a wrapper, but the shell refuses it, so git never runs.
  • 80 git -C $R checkout $BR: the unquoted value now reads like the quoted one, and develop denies git -C "$R" checkout $BR (bash and dash split BR="HEAD f" into a tree-ish and a path). The same holds for git -C $R branch -D feat, which _BRANCH_D_PLAIN_RE only allows for a plain -C path.
  • 26: a substitution in the subcommand slot once the value is joined (develop's existing "any sub" reading of $(..)), or a noise character glued into the subcommand or a flag (clean+ -fd, --h+ard), which develop reads as tokens too. One of these really is destructive (-C '' $X reset --hard); the oracle missed it because of a develop gap filed in gate: irrecoverable wrapper and assignment walks read a split variable as the command #382.
  • 17: shell syntax errors or a failed redirect (2>/d!ev/null), so git never runs.

Timing (whole gate process, python3 3.14; old vs new): 4,000 -C $R + status 0.06 s / 0.07 s; 140 KB of $a words 0.18 s / 0.24 s; 140 KB glued $a value 0.23 s / 0.29 s; 132 KB of -c k=$a:b@c 0.18 s / 0.23 s. Replay worst case 0.07 s, fuzz 0.06 s. The differential and the mutants ran under /usr/bin/python3 3.9.6, the test suites under 3.14. _pushback_chars and instream.tell() are shlex internals: a missing _pushback_chars falls back to (), which only loses glue and never loses a develop deny.

Residue (#382)

The same one-token reading in other walks still allows sudo -u $U git reset --hard, env -u $X git ..., timeout $T git ... and X=$Y rm -rf build / X=$Y git reset --hard. Separately, git -C "" reset --hard is allowed because _split_ops("") drops the empty token. Those fixes deny non-git commands too, so they need their own differential. A literal $ value (git -C $ reset) and git $X reset --hard read as before.

No manifest bump.

…d (GH #375)

shlex splits one shell word at a character outside its wordchars, so
`git -C $PWD reset --hard` reached the git walk as -C, $, PWD and PWD was
read as the subcommand. _tokens now marks a token cut from the one before
it (_Glued), and the git block also checks a window whose global flags are
joined back into shell words. The token reading stays, so a verdict can
only tighten.
@wasikarn
wasikarn merged commit 51e562f into develop Oct 3, 2026
3 checks passed
@wasikarn
wasikarn deleted the claude/gate-375-git-c-var branch October 3, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gate: irrecoverable misses a git -C value given as an unquoted variable

1 participant